-
Notifications
You must be signed in to change notification settings - Fork 38
Add common controller based on presumed tfcli interface #14
Conversation
c4904b2
to
887f311
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have reviewed the parts I could but in some cases I'm not sure what the alternatives are since the underlying CLI implementation is not there yet.
pkg/conversion/cli.go
Outdated
|
||
// Observe is a Terraform Cli implementation for Observe function of Adapter interface. | ||
func (t *Cli) Observe(tr resource.Terraformed) (ObserveResult, error) { | ||
b, err := t.getClientBuilderForResource(tr) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see that we prepare the TF workspace in the filesystem and then create a client specific for each operation by calling Build{Observe,Create,Update}Client
. Would it make sense to prepare the workspace in Connect
method of ExternalConnector
implementation? I can't see the content of Build{Observe,Create,Update}Client
calls but I have a hunch that once we prepare the workspace (with terraform.tfstate
and main.hcl.json
) they all can use the same folder to run the commands.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe this is already the case. With the help of a handler (which we use CR uid for that), we always use the same workspace. However, with current structure, this implementation detail is hidden from the upper layer controller. So, I wouldn't prefer to explicitly do something to prepare the workspace in the Connect
method.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I got your point after our offline discussion and updated it accordingly.
We now build client once in connect method which is enabled by the reworked interface that we ended up with together.
import jsoniter "github.com/json-iterator/go" | ||
|
||
// TFParser is a json parser to marshal/unmarshal using "tf" tag. | ||
var TFParser = jsoniter.Config{TagKey: "tf"}.Froze() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can borrow some properties from the fastest configuration.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are we confident that configuration wouldn't cause any data loss for any generated resource?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Floats lose precision up until 6-digits, which is fine I believe but not a strong opinion here.
pkg/tfcli/error.go
Outdated
return []string{"Unknown", "Observe", "Create", "Update", "Delete"}[o] | ||
} | ||
|
||
type OperationInProgressError struct { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we can return informational fields in cases where the operation is in progress rather than an error struct because it's a valid state to be in progress rather than an erroneous one.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually, we discussed this option with @ulucinar and finally agreed that it makes more sense to it this way since trying to kick an operation while another one is in progress is an error case from tfcli point of view. Handling this on the upper layer (cli adapter) sounds more reasonable. In other words, it is a valid state for controller/reconciler but not a valid one for tfcli.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I agree. We would not like to allow concurrent Terraform operations (technically commands) in the same Terraform workspace. From tfcli
's PoV it's an error to request a new operation before an ongoing one completes. This is also in sync with Terraform CLI's behaviour: if an ongoing Terraform command holds the state lock and we try to run a new command, the latter command cannot acquire the lock and fails. Thus, the proposal was to return an error to the caller, who wants to initiate a new operation via tfcli
, if there is an already ongoing operation. And similar to k8s.io/apimachinery/pkg/api/errors
package, provide utilities to deduce error types so that the caller can take appropriate actions.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just a few mostly nitpicks. :)
pkg/conversion/adapter.go
Outdated
|
||
// ObserveResult represents result of an observe operation | ||
type ObserveResult struct { | ||
Completed bool |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it make any sense for these Completed
fields to be a channel rather than a boolean? i.e. One that blocked until the action was complete, similar to a context's Done
channel?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure. We are calling Observe
in each reconcile and do not carry any information/state to the next reconcile loop. So, couldn't figure out how to leverage such a channel in this context.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The bool
used here is not meant to block its observer as it is currently part of an async interface. However, if we decide to make tfcli.Observe
blocking, then we can remove it.
Signed-off-by: Hasan Turken <turkenh@gmail.com>
Signed-off-by: Hasan Turken <turkenh@gmail.com>
Signed-off-by: Hasan Turken <turkenh@gmail.com>
Signed-off-by: Hasan Turken <turkenh@gmail.com>
Signed-off-by: Hasan Turken <turkenh@gmail.com>
Signed-off-by: Hasan Turken <turkenh@gmail.com>
Signed-off-by: Hasan Turken <turkenh@gmail.com>
Signed-off-by: Hasan Turken <turkenh@gmail.com>
Signed-off-by: Hasan Turken <turkenh@gmail.com>
Signed-off-by: Hasan Turken <turkenh@gmail.com>
Signed-off-by: Hasan Turken <turkenh@gmail.com>
Signed-off-by: Hasan Turken <turkenh@gmail.com>
Signed-off-by: Hasan Turken <turkenh@gmail.com>
Signed-off-by: Hasan Turken <turkenh@gmail.com>
4eebc77
to
64ec148
Compare
Signed-off-by: Hasan Turken <turkenh@gmail.com>
Signed-off-by: Hasan Turken <turkenh@gmail.com>
5514a31
to
514f95d
Compare
@muvaf @ulucinar I've updated the PR based on our latest discussions today. It was a bit tricky since it is not possible to test without the actual tfcli implementation but I believe I covered most of the cases that we can think of. The most challenging part was properly persisting annotations (state+external-name) & spec (late-init) and status without losing any data and I would prefer to get back to that with a later PR when things are testable. That said, I would suggest merging this one if you don't see anything blocking, so we can iterate more easily without dealing with open PRs, rebases and conflicts. |
tfcli, err := conversion.BuildClientForResource(tfcb, tr) | ||
*/ | ||
|
||
tfcli, err := conversion.BuildClientForResource(nil, tr) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I saw the discussion that assumes the TF workspace is initialized when we build the client. It's not the case atm because tfcli.Builder.Build
is a synchronous call and workspace building is potentially a long running task.
This has not been an issue so far because the tfcli.Client
interface is fully async (Observe
, Create
, Destroy
, etc.), excluding some getters (GetState
, GetHandle
, etc.) During each call tfcli.Client
initializes a workspace if needed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We may operate under the assumption that workspace initialization is not costly (because we have the means to do that by avoiding provider plugin downloads each time a workspace is initialized) but I do not like the idea of operating under extra assumptions. For example, development would require special setup & configuration on a developer's machine.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We may operate under the assumption that workspace initialization is not costly (because we have the means to do that by avoiding provider plugin downloads each time a workspace is initialized) but I do not like the idea of operating under extra assumptions.
As we discussed offline, it seems like how we build the workspace affects whether Observe
should be blocking or not as well. I think it should be safe to assume that the workspace initialization will not include plugin download because we will ship the binary in the container image. For development experience, I have a hunch that we can come up with ways that'll make it closer to production. For example, we can put the binary to a directory that is usable in local machines as well, like /usr/bin
, and require it to be downloaded before running main.go
in both container and development machine. We could have a local dev script like this one to download it for them.
IMO, the extra cost in the code for making workspace building async is not worth the development experience we'll get by assuming developer might not have the plugin and want terrajet download it automatically.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM! No big concerns apart from the case where the user gives external name and resource does not exist.
pkg/conversion/adapter.go
Outdated
// An Adapter is used to interact with terraform managed resources | ||
type Adapter interface { | ||
Observe(ctx context.Context, tr resource.Terraformed) (Observation, error) | ||
Update(ctx context.Context, tr resource.Terraformed) (Update, error) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Update(ctx context.Context, tr resource.Terraformed) (Update, error) | |
Apply(ctx context.Context, tr resource.Terraformed) (Apply, error) |
Or maybe CreateOrUpdate
or Upsert
, something to make it clear that it can create as well.
if xpmeta.GetExternalName(tr) == "" { | ||
// Terraform stores id for the external resource as an attribute in the | ||
// resource state. Key for the attribute holding external identifier is | ||
// resource specific. We rely on GetTerraformResourceIdField() function | ||
// to find out that key. | ||
stAttr := map[string]interface{}{} | ||
if err = JSParser.Unmarshal(st.GetAttributes(), &stAttr); err != nil { | ||
return nil, errors.Wrap(err, "cannot parse state attributes") | ||
} | ||
|
||
id, exists := stAttr[tr.GetTerraformResourceIdField()] | ||
if !exists { | ||
return nil, errors.Wrapf(err, "no value for id field: %s", tr.GetTerraformResourceIdField()) | ||
} | ||
extID, ok := id.(string) | ||
if !ok { | ||
return nil, errors.Wrap(err, "id field is not a string") | ||
} | ||
xpmeta.SetExternalName(tr, extID) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this be an Initializer
, alternative to NameAsExternalName
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am not sure. If we make it an Initializer, then setting the external name will be done in the next reconcile parsing the stored state annotation (again). I don't see an immediate problem with this but also do not see much of a value, would prefer to consider again later.
// resource specific. We rely on GetTerraformResourceIdField() function | ||
// to find out that key. | ||
stAttr := map[string]interface{}{} | ||
if err = JSParser.Unmarshal(st.GetAttributes(), &stAttr); err != nil { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is a Get
in jsoniterator that could help here.
import jsoniter "github.com/json-iterator/go" | ||
|
||
// TFParser is a json parser to marshal/unmarshal using "tf" tag. | ||
var TFParser = jsoniter.Config{TagKey: "tf"}.Froze() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Floats lose precision up until 6-digits, which is fine I believe but not a strong opinion here.
tfcli, err := conversion.BuildClientForResource(tfcb, tr) | ||
*/ | ||
|
||
tfcli, err := conversion.BuildClientForResource(nil, tr) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We may operate under the assumption that workspace initialization is not costly (because we have the means to do that by avoiding provider plugin downloads each time a workspace is initialized) but I do not like the idea of operating under extra assumptions.
As we discussed offline, it seems like how we build the workspace affects whether Observe
should be blocking or not as well. I think it should be safe to assume that the workspace initialization will not include plugin download because we will ship the binary in the container image. For development experience, I have a hunch that we can come up with ways that'll make it closer to production. For example, we can put the binary to a directory that is usable in local machines as well, like /usr/bin
, and require it to be downloaded before running main.go
in both container and development machine. We could have a local dev script like this one to download it for them.
IMO, the extra cost in the code for making workspace building async is not worth the development experience we'll get by assuming developer might not have the plugin and want terrajet download it automatically.
pkg/tfcli/error.go
Outdated
return []string{"Unknown", "Observe", "Create", "Update", "Delete"}[o] | ||
} | ||
|
||
type OperationInProgressError struct { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good!
Signed-off-by: Hasan Turken <turkenh@gmail.com>
Signed-off-by: Hasan Turken <turkenh@gmail.com>
This PR adds a common controller implementation based on a presumed terraform cli interface.
Generated controllers will call
terraform.SetupController
to setup the controllers. For example:We will also need to generate the required function on managed resource structs to satisfy the
Terraformed
interface.For example: